Skip to content

[ATOM] accelerate lm cache: load the CPU prefix when the Qwen3.5 HBM hit is off the chunk grid - #2562

Merged
ZhangLirong-amd merged 8 commits into
mainfrom
lingzha/qwen35-lmcache
Oct 28, 2026
Merged

ZhangLirong-amd merged 8 commits into
mainfrom
lingzha/qwen35-lmcache

Conversation

@ZLkanyo009

@ZLkanyo009 ZLkanyo009 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

On the in-process LMCache path (ATOM_KV_OFFLOAD=lmcache), a Qwen3.5 request can resume from the CPU tier only at a boundary B where the KV for [0, B) and a GDN state checkpoint at B both exist. The KV leg reads LMCache from the HBM prefix hit up to B. Two constraints decide where that read may start:

  • LMCache retrieves whole chunks. The number of skipped leading tokens must be a multiple of LMCACHE_CHUNK_SIZE (256).
  • Blocks below the HBM hit are claimed from the prefix cache. They are indexed and other sequences may be reading them, so nothing may write them.

The HBM hit sits on the 16-token hash-block grid. Agentic resumes land on the previous prompt's end, which is rarely a multiple of 256. With an unaligned hit there was no legal start: rounding down writes into a shared block, and rounding up leaves a gap the forward skips. _joint_kv_boundary refused the joint load (hbm_off_chunk_grid). The request then recomputed everything above the HBM hit, even when LMCache held the KV and the state tier held a rung far above it.

This change moves the shared/private split instead of the start:

  • _joint_kv_boundary no longer refuses an unaligned HBM hit. claim_tokens is the compressed-hit chunk floor when that floor is already at or above the gated hit, so resident KV stays shared. Only a floor still below the hit steps down to the hit's own chunk floor.
  • record_allocation checks that floor before the joint is counted. The extra demand is the in-use blocks in [floor, hbm), added to the admission's existing demand, and the checkpoint about to be pinned is protected. If that does not fit, the joint is skipped (partial_chunk_no_room) and the request keeps its HBM hit. The skip is not also counted as a boundary. The admission's demand comes from the same can_allocate probe (AdmissionProbe.num_new_blocks, handed to record_allocation(seq, probe)). A probe without it skips the joint (no_probe_demand) instead of committing it unchecked.
  • allocate then stops the shared claim at that floor. The partial chunk gets fresh private blocks after the restore is pinned. num_cached_tokens drops to the floor. The KV leg starts at max(hbm, claim_tokens) in the K3 connector, which is on the grid, and writes only private blocks up to the chunk covering the state rung.
  • If either leg of an unaligned joint fails, retreat_joint_to_hbm puts the HBM hit's shared blocks back and binds that hit's state into the slot the admission holds. It finds the hit on _prompt_hashes(seq), the chain can_allocate walked, which may be the frontend's, and compares tokens on each block. Only when the hit's blocks or state are gone does the request disown to token 0.

Example from the new test (block 4 tokens, chunk 16): the HBM hit is 20 tokens and the state tier has a rung at 32. Before, the joint load was refused and the forward ran from 20. After, [0, 16) stays shared, [16, 20) moves to a private block, LMCache loads [16, 32), the state loads at 32, and the forward runs from 32.

Cost: the private refill is at most one chunk — 255 tokens and 15 blocks — and only when the compressed hit does not already reach a higher chunk boundary. A longer resident prefix stays shared and is not refetched. Aligned hits and models without per-request state are unchanged.

Command

Unit tests:

python -m pytest tests/test_state_checkpoint.py -q
python -m pytest tests/test_block_manager.py tests/test_lmcache_*.py \
  tests/test_prefill_prefix_vs_native.py tests/test_prefix_cache_accuracy.py -q

188 and 716 passed on the previous revision. This revision adds the live-tail exact-fit case, the compressed-hit floor above the gated hit, and the partial_chunk_no_room fallback. tests/test_state_checkpoint.py and tests/test_block_manager.py pass (276), as do the joint and unaligned cases in tests/test_lmcache_offload_connector.py (18). The original case is TestPagedCopyCheckpoint::test_an_unaligned_hbm_hit_still_reaches_the_rung_above_it: the hit is gated to 5 blocks (20 tokens), the joint boundary is 8 blocks, claim_tokens == 16, block 3 is still the shared block, block 4 is a fresh block, and num_cached_tokens == 16.

Serving, InferenceX AgentX at concurrency 56. Model /models/Qwen/Qwen3.5-397B-A17B-FP8, TP4, FP8 KV, LMCache CPU 253 GiB per rank:

HIP_VISIBLE_DEVICES=0,1,2,3 AITER_LOG_LEVEL=WARNING PYTHONHASHSEED=0 \
ATOM_ENABLE_QK_NORM_ROPE_CACHE_QUANT_FUSION=1 \
MAX_NUM_SEQS=112 GPU_MEM_UTIL=0.8 \
ATOM_KV_OFFLOAD=lmcache \
LMCACHE_LOCAL_CPU=True LMCACHE_NUMA_MODE=auto \
LMCACHE_MAX_LOCAL_CPU_SIZE=253 LMCACHE_CHUNK_SIZE=256 \
OFFLOAD_MIN_LOAD_TOKENS=8192 \
bash scripts/start_atom_server.sh /models/Qwen/Qwen3.5-397B-A17B-FP8 4 8000 \
  --enable-prefix-caching --method mtp --num-speculative-tokens 3 --block-size 16 \
  --cudagraph-capture-sizes "[1,2,4,8,12,16,20,24,28,32,36,40,44,48,52,56,60,64,68,72,76,80,84,88,92,96,100,104,108,112]"

aiperf profile --scenario inferencex-agentx-mvp \
  --url http://127.0.0.1:8000 --endpoint /v1/chat/completions --endpoint-type chat --streaming \
  --model /models/Qwen/Qwen3.5-397B-A17B-FP8 --tokenizer /models/Qwen/Qwen3.5-397B-A17B-FP8 \
  --tokenizer-trust-remote-code --apply-chat-template \
  --public-dataset semianalysis_cc_traces_weka_062126_256k --num-dataset-entries 393 \
  --concurrency 56 --benchmark-duration 3600 --random-seed 42 \
  --warmup-requests-per-lane 10 --warmup-grace-period 1800 --trace-idle-gap-cap-seconds 300 \
  --trajectory-start-min-ratio 0.25 --trajectory-start-max-ratio 0.75 \
  --failed-request-threshold 0.10 --use-server-token-count --no-gpu-telemetry \
  --stats-interval 30 --slice-duration 1.0

PYTHONHASHSEED=0 is required, or cache keys will not match across workers. The state checkpoint interval is the default 8192 and the prefill chunk is 16384.

Accuracy, same protocol as #2519 (TP8, FP8 KV, in-process LMCache, 32 GB CPU pool):

AITER_LOG_LEVEL=WARNING PYTHONHASHSEED=0 \
ATOM_KV_OFFLOAD=lmcache \
LMCACHE_LOCAL_CPU=True LMCACHE_MAX_LOCAL_CPU_SIZE=32 LMCACHE_CHUNK_SIZE=256 \
MAX_NUM_SEQS=65 \
bash scripts/start_atom_server.sh /models/Qwen/Qwen3.5-397B-A17B-FP8 8 8000 \
  --enable-prefix-caching

NUM_CONCURRENT=65 bash scripts/run_gsm8k_eval.sh \
  /models/Qwen/Qwen3.5-397B-A17B-FP8 8000 3

With CHAT unset, run_gsm8k_eval.sh uses local-completions, 3-shot, num_concurrent=65.

Performance

One 3600 s run each, with identical server and client settings. "Before" and "This PR" use LMCache's default rank-0 lookup. The third column runs this PR with all-rank lookup, a configuration change described below that is not part of this PR's code. The hit rate is over successful requests in the measured phase, sum(cached_tokens) / sum(prompt_tokens), with warmup excluded.

Before This PR This PR + all-rank lookup
Successful requests 3796 3876 3975
Failed requests 1 1 5
Prompt cache hit 93.60% 94.33% 94.64%
Server GPU / CPU tier (measured phase, prompt denominator) 91.83% / 1.68% 92.36% / 1.97% 92.49% / 2.15%
aiperf theoretical prefix hit 95.35% 95.34% 95.30%
LMCache load failures (server log) 20 16 6
Output throughput 927.3 tok/s (231.8 per GPU) 958.7 tok/s (239.7 per GPU) 974.4 tok/s (243.6 per GPU)
TTFT p50 / p99 1396 / 13335 ms 1373 / 9544 ms 1389 / 9031 ms
ITL p50 51.9 ms 49.0 ms 48.8 ms

Joint-load counters at the end of each run (lifetime, including warmup):

before:   joint kv: boundaries=78 (state_hbm=1 state_tier=77) | hbm_off_chunk_grid=250 lmcache_within_hbm=3949 no_rung_above_hbm=149
this PR:  joint kv: boundaries=99 (state_hbm=0 state_tier=99) | lmcache_within_hbm=4012 no_rung_above_hbm=394
all-rank: joint kv: boundaries=92 (state_hbm=0 state_tier=92) | lmcache_within_hbm=4109 no_rung_above_hbm=400

hbm_off_chunk_grid is gone. Admissions it used to refuse now reach the rung search. Most find no CPU rung above the HBM hit (no_rung_above_hbm), and the rest become joint loads. partial_chunk_no_room did not fire in either run.

A fixed-duration replay serves a different set and order of requests in each run, and run-to-run variation was not measured, so the 0.73-point difference between "Before" and "This PR" is not attributed to this change alone.

Joint-load failures against boundaries

Load-channel failures divided by boundaries (lifetime, including warmup):

Load failures Boundaries Failures / boundaries
This PR 16 99 16/99 (16%)
This PR + all-rank lookup 6 92 6/92 (6.5%)

On the rank-0 run, every KV retrieve except one started at token 0 (required == lookup total). The exception, request 3643, transferred 31488 tokens of a 32768 lookup, so the prefix below the transfer was 1280 tokens. None of the 16 failures dropped a long HBM hit in order to extend a short distance past it. Ten of the 16 are the rank-0 lookup misses already described (three ranks retrieve 0, one retrieves the chunk). The all-rank run removes that pattern; its 6 remaining failures are state-leg failures, which still disown to token 0. That is 6.5% of boundaries, not a case of recomputing ~200k tokens to save one chunk.

Re-run after the review fixes (05e502613)

Same rank-0 protocol as the "This PR" column above (TP4, MTP3, concurrency 56, 3600 s measured phase, warmup excluded). One run, on 2026-10-15.

This PR (table above) 05e502613
Successful requests 3876 4093
Failed requests 1 1 connection reset; 14 still in flight when the 30 s grace expired
Prompt cache hit 94.33% 94.51% (357,647,440 / 378,433,807)
aiperf theoretical prefix hit 95.34% 95.45%
Output throughput 958.7 tok/s (239.7 per GPU) 1009.0 tok/s (252.2 per GPU)
TTFT p50 / p99 1373 / 9544 ms 1219 / 7343 ms
ITL p50 49.0 ms 45.4 ms

The request mix differs across fixed-duration replays, as noted above, so the higher throughput and the 0.18-point hit-rate difference are not attributed to the review fixes alone. Nothing in this run is worse than the previous "This PR" column.

Re-run after merging main (658aa3891)

Same rank-0 protocol (TP4, MTP3, concurrency 56, 3600 s measured phase, warmup excluded), on 2026-10-20, after merging main. aiperf 0.12.0 ignores --warmup-grace-period on this scenario, so the warmup cap was --agentic-warmup-grace-period 1800. Warmup finished in 367 s with 0 errors. Figures are from profile_export_aiperf.json.

05e502613 658aa3891
Successful requests 4093 4268
Failed requests 1 connection reset; 14 still in flight when the 30 s grace expired 2 connection resets; 18 still in flight when the window closed
Prompt cache hit 94.51% 94.49% (371,758,848 / 393,436,548)
aiperf theoretical prefix hit 95.45% 95.39%
Output throughput 1009.0 tok/s (252.2 per GPU) 1048.1 tok/s (262.0 per GPU)
TTFT p50 / p99 1219 / 7343 ms 710 / 5504 ms
ITL p50 45.4 ms 49.7 ms

Lifetime joint counter at the end of the run, warmup included:

658aa3891: joint kv: boundaries=97 (state_hbm=2 state_tier=95) | lmcache_within_hbm=4419 no_rung_above_hbm=384

partial_chunk_no_room did not fire. state_offload_loads_failed stayed 0. The measured-phase GPU/CPU tier split is not in this export. The cumulative tier line at the end of profiling includes warmup (HBM 90.99%, CPU 1.98%) and is not comparable to the measured-phase row above.

The request mix differs across fixed-duration replays, and this commit also contains main's draft-lookahead and midstep checkpoint changes, so the throughput and ITL differences are not attributed to the merge resolution.

All-rank lookup

LMCache starts a lookup server only on the workers listed in lookup_server_worker_ids, and ATOM defaults that list to [0] (atom/kv_transfer/offload/config.py). A lookup pins the matched CPU objects and refreshes their LRU entries only on the workers it queries. With rank 0 alone, ranks 1–3 evict their shards of the same chunk independently. A retrieve then finds a chunk missing on some ranks. The load needs every rank, so the whole load fails and the request recomputes from token 0.

In the "This PR" run, 10 of the 16 load failures had three ranks retrieve 0 tokens and one rank retrieve everything. We matched the 3876 successful requests one-to-one to the SGLang run on the same trace (InferenceX run 36743632401). On 8 of the failed requests, SGLang served 1,296,000 cached tokens that ATOM recomputed.

The third column only adds:

export ATOM_KV_OFFLOAD_EXTRA_CONFIG='{"lmcache.lookup_server_worker_ids":[0,1,2,3]}'
  • The three-zeros-one-full pattern went from 10 to 0, and load failures from 16 to 6. All 6 remaining are state-load failures (state_offload_loads_failed=6).
  • On the 3788 requests both runs served successfully, all-rank lookup cached 905,024 more tokens: 94.35% → 94.62%.
  • The run had 5 empty responses (InvalidInferenceResultError, no content returned), against 1 connection reset in the "This PR" run. The root cause is not identified. The three checked so far had no CPU state load while they ran.

This PR does not change the default. The lookup scope stays rank 0 unless the configuration above is set.

Acc

Full GSM8K, 1319 questions, local-completions, 3-shot, concurrency 65, max_num_seqs=65, in-process LMCache, on this commit. The request phase took about 7 min.

Metric This PR #2519 (before this change) Gate
flexible-extract 0.8749 ± 0.0091 0.8795 ± 0.0090 CI threshold 0.85; same-protocol CI baseline 0.8605
strict-match 0.8613 ± 0.0095 0.8643 ± 0.0094 —

Both metrics are 0.003–0.005 lower than in #2519, within one standard error of either run.

Re-run on 05e502613, same protocol (TP8, local-completions, 3-shot, concurrency 65, max_num_seqs=65, in-process LMCache, 32 GB CPU pool). The request phase took about 6 min.

Metric 05e502613 This PR (table above)
flexible-extract 0.8749 ± 0.0091 0.8749 ± 0.0091
strict-match 0.8590 ± 0.0096 0.8613 ± 0.0095

flexible-extract matches the previous run exactly. strict-match is 0.0023 lower, inside one standard error of either run.

Re-run on 658aa3891, same protocol. The request phase took about 5 min.

Metric 658aa3891 05e502613
flexible-extract 0.8704 ± 0.0093 0.8749 ± 0.0091
strict-match 0.8544 ± 0.0097 0.8590 ± 0.0096

Both stay above the 0.85 threshold. flexible-extract is 0.0045 lower than 05e502613, inside one standard error of either run.

@github-actions

Copy link
Copy Markdown
Contributor

🏷️ CI Guide

Runs automatically on every eligible PR before approval:

  • ✅ Pre Checkin: Black, Ruff, catalog schema validation, non-GPU unit tests

Heavy model tests:

  • ✅ Run after the PR is approved and Pre Checkin passes
  • ✅ Run immediately when an approval review is submitted
  • ✅ Can be requested before approval with labels
Label Tests
ci:full Run all heavy PR model tests: native ATOM, vLLM, and SGLang
ci:atom Run native ATOM model accuracy tests
ci:vllm Run ATOM vLLM OOT model accuracy tests
ci:sglang Run ATOM SGLang model accuracy tests

Heavy jobs are skipped when the PR is not approved and no matching ci:* label is present.
Add labels via the sidebar or gh pr edit 2562 --add-label <label>

@ZLkanyo009 ZLkanyo009 changed the title [ATOM] lm cache: load the CPU prefix when the Qwen3.5 HBM hit is off … [ATOM] lm cache: load the CPU prefix when the Qwen3.5 HBM hit is off the chunk grid Oct 14, 2026
@ZLkanyo009 ZLkanyo009 changed the title [ATOM] lm cache: load the CPU prefix when the Qwen3.5 HBM hit is off the chunk grid [ATOM] accelerate lm cache: load the CPU prefix when the Qwen3.5 HBM hit is off the chunk grid Oct 14, 2026
@ZLkanyo009
ZLkanyo009 marked this pull request as ready for review October 14, 2026 02:44
@wuhuikx
wuhuikx requested a review from yhl-amd October 14, 2026 06:13
…the chunk grid

An HBM prefix hit that is not a multiple of the LMCache chunk used to
refuse the whole CPU prefix (hbm_off_chunk_grid), even when the CPU tier
held a state rung above it. Start the KV leg on the chunk floor instead,
reload that partial chunk into private blocks so no shared block is
written, and keep the rung above the real HBM hit. If the pool cannot
back the private blocks, drop the joint load and keep the HBM hit.
@ZLkanyo009
ZLkanyo009 force-pushed the lingzha/qwen35-lmcache branch from 9433d1a to 711ccf5 Compare October 15, 2026 04:59

@yhl-amd yhl-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. The direction makes sense, and the scope is limited to the qwen/kimi_k3 offload layouts. Kimi's recipe has hash block == LMCache chunk, so in practice only Qwen3-Next/3.5 change behaviour. A few things I'd like addressed before merge (I only read the code, I didn't run the tests):

  1. Pool check in allocate (inline, L1111). This can raise AssertionError from _fresh_block when the pool fits exactly. It can also evict the joint's own HBM checkpoint before that checkpoint is pinned.
  2. The unaligned branch drops the compressed-hit claim (inline, L841). This refetches and duplicates KV that is still resident, and in that case the "≤255 tokens / ≤15 blocks" cost bound in the description does not hold.
  3. Failure exposure. Unaligned hits used to keep their HBM hit. Now they take the joint path, where a failure on either leg disowns to token 0. With a large hit and a nearby rung, one failure costs much more than one success saves (e.g. recomputing 200k tokens vs saving 8k). Could you report the joint-load failure rate against boundaries, so we can judge the trade?
  4. Stale invariants. These comments no longer hold after this change: in OffloadJoint (sequence.py), L222 ("num_cached_tokens stays at the HBM prefix…") and L232 (claim_tokens "Above num_cached_tokens, not below it"); and the K3 connector's comment that the blocks below start are shared (kimi_k3/connector.py L811).
  5. Test coverage (inline on the new test).

Not a blocker, but on design: a possibly cleaner alternative is to keep the shared claim up to hbm and still retrieve from the chunk floor, but have the block GPU connector drop the leading [floor, hbm) blocks on unpack (map them to a scratch block or skip them). num_cached_tokens and the prefix accounting then stay unchanged, and the private-block path in allocate goes away.

Comment thread atom/model_engine/block_manager.py Resolved
Comment thread atom/model_engine/block_manager.py Resolved
Comment thread atom/model_engine/block_manager.py Resolved
Comment thread tests/test_state_checkpoint.py Resolved
Keep a compressed-hit chunk floor when it is already a legal start, and
skip an unaligned joint before it is recorded when the in-use tail does
not fit without spending the checkpoint about to be pinned.
@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

Addressed the review on 05e502613.

  1. Pool check. The extra demand is the in-use blocks in [claim, hbm), added to the admission's existing demand. record_allocation re-checks that total with the checkpoint about to be pinned (the boundary when state_from_hbm, otherwise the HBM hit) and skips the joint before it is counted. allocate no longer calls _ensure_page_units on the tail, so it cannot evict that checkpoint before the restore is pinned. partial_chunk_no_room is only a skip, not also a boundary. claim_tokens % hash_block is an assert.

  2. Compressed-hit claim. The chunk floor of the compressed hit is kept when it is already at or above the gated hit. The HBM floor is used only when that claim is still below the hit, so the private refill stays within one chunk (≤255 tokens / ≤15 blocks).

  3. Failure rate against boundaries. Rank-0 lookup: 16 load-channel failures / 99 boundaries (16%). All-rank lookup: 6 / 92 (6.5%). On the rank-0 run every KV retrieve except one started at token 0 (required == lookup total). The exception, request 3643, transferred 31488 of a 32768 lookup (1280 tokens below the transfer). None of the 16 dropped a long HBM hit to extend a short distance past it. Ten of the 16 are the rank-0 lookup misses; the all-rank run's remaining 6 are state-leg failures, which still disown to token 0.

  4. Stale comments in OffloadJoint and the K3 connector now match the floor: num_cached_tokens can drop to the chunk floor, claim_tokens is at or above that after allocate, and blocks strictly below start are the shared prefix while [floor, hbm) is private.

  5. Tests. Live tail on an exact-fit pool (keeps the HBM hit, shares the block a live seq holds). Live tail with room (the held block is not replaced). Compressed-hit floor above the gated hit (claim 32, not 16). partial_chunk_no_room keeps the hit, does not count a boundary, and does not spend the rung image.

The connector-side alternative (keep num_cached_tokens at the HBM hit and drop [floor, hbm) on unpack) is unchanged. The private-block path stays, with the fit check moved before the commit.

@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

Re-ran the same protocols on 05e502613.

GSM8K (TP8, 3-shot, concurrency 65): flexible-extract 0.8749 ± 0.0091 (same as the previous run), strict-match 0.8590 ± 0.0096 (0.0023 lower, inside one standard error).

c56, rank-0 lookup, 3600 s: 4093 successful requests, 1 connection reset, 14 cancelled when the 30 s grace expired. Prompt cache hit 94.51% (was 94.33%). Output throughput 1009.0 tok/s (252.2 per GPU; was 958.7). TTFT p50/p99 1219/7343 ms (was 1373/9544). ITL p50 45.4 ms (was 49.0). The request mix of a fixed-duration replay differs across runs, so the small gains are not attributed to the review fixes alone. Nothing is worse than the previous column.

Details are in the PR description under "Re-run after the review fixes".

yhl-amd
yhl-amd previously approved these changes Oct 15, 2026

@yhl-amd yhl-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, all five points from my previous review are addressed on 05e502613:

  • The pool check moved into _fit_unaligned_joint before the commit. I checked the arithmetic: the re-checked demand, n - in_use([0, claim)), matches what allocate actually takes. allocate no longer evicts before the restore is pinned.
  • When the compressed hit's chunk floor is already at or above the gated hit, it is kept as the claim. The "≤255 tokens / ≤15 blocks" bound now holds.
  • The failure-rate data answers the exposure question.
  • The comments in OffloadJoint and the K3 connector now match the behaviour.
  • The four new tests cover the cases I asked for.

I also traced the KV leg's start for both the compressed-floor case and the lowered-num_cached_tokens case, and the DecodeScheduler.allocate_waiting path. Each one writes only fresh blocks. CI is green on this commit.

Non-blocking follow-ups, fine as separate PRs:

  1. This was already there before this PR and is only partly covered here. can_allocate's fit gate protects the HBM-hit checkpoint, but a state_from_hbm joint pins the boundary checkpoint in allocate. If the hit image is already pinned by another restore, the gate over-counts by one image (~units_per_checkpoint units), so _fresh_block can still raise on a tight pool. _fit_unaligned_joint uses the right protected hash, but it only runs when an in-use tail adds demand. Re-checking whenever decision.state_from_hbm would close this, for aligned joints too. Rare in practice: state_hbm was 0 in both runs.
  2. _fit_unaligned_joint re-walks the whole hit to recount num_new_blocks, duplicating the loop in can_allocate. It only runs when the tail adds demand, so the cost is small. Carrying the count forward would remove the duplicate loop.
  3. With the default rank-0 lookup, the joint-load failure rate is 16%, against 6.5% with all-rank lookup. Your analysis makes a good case for changing the lookup_server_worker_ids default in a separate PR.

LGTM.

@valarLip valarLip left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 05e5026. The main point is reproduced in a unit test. Under an exact-fit pool, an unaligned joint can now raise in allocate, which takes down the scheduler, where the merge base refused it cleanly.

  1. The re-check misses the pinned checkpoint. _fit_unaligned_joint re-checks the pool only when the tail adds in-use demand (extra > 0), and returns at once when claim >= hbm. Otherwise it keeps can_allocate's check, which protected the HBM-hit checkpoint. allocate, however, pins the boundary checkpoint and only then takes fresh blocks.
  2. The aligned case has the same root problem on the merge base. The fix is narrower than the bug.
  3. An unaligned admission is now all-or-nothing. Any failure on either leg recomputes from token 0 instead of resuming at the HBM hit.

Repro: block 4, chunk 16. The hit rung (5 blocks) is only in the CPU tier and the boundary rung (8) is in HBM. The tail block [16, 20) is cached and free, and free = demand − units_per_checkpoint. can_allocate returns 5 and commits claim 16 / boundary 32. Then allocate(second, 5) pins checkpoint 8 and _fresh_block raises AssertionError: No PAGE unit available for a fresh KV block. On 6e9b3e1 the same admission is refused as hbm_off_chunk_grid and resumes at 20. With an aligned 4-block hit, 6e9b3e1 crashes the same way. The test is built on TestPagedCopyCheckpoint in tests/test_state_checkpoint.py; I can push it if useful.

Not inline, because these lines are not in the diff:

  • Where the fix belongs. The root cause is that, for any joint whose state comes from HBM, can_allocate's protected_hash (block_hashes[num_cached_blocks - 1]) is not the checkpoint allocate pins. Re-checking every state_from_hbm joint in record_allocation against protected=boundary_hash, with demand n − used[0, min(claim, hbm)), would fix the aligned case too and replace the special-case helper with its extra == 0 exit.
  • A docstring that is no longer true. _fresh_block's docstring says its raise is unreachable through Scheduler because "allocate protects the one checkpoint it is about to pin". After this PR that is reachable for every unaligned HBM hit.
  • Metrics. num_cached_tokens is now the chunk floor, so prefix_cache_hit_tokens and the GPU/CPU tier split count the HBM-resident partial chunk [floor, hbm) as a CPU-tier load (scheduler.py around 2315). The PR's Before vs This-PR tier comparison is therefore not measured the same way.

Details inline.

Comment thread atom/model_engine/block_manager.py Outdated
block_id = self.kv.lookup(block_hashes[i])
if block_id != -1 and self.kv.is_used(block_id):
extra += 1
if extra == 0:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two early returns (claim_tokens >= hbm_tokens above, extra == 0 here) keep can_allocate's check, which protected block_hashes[num_cached_blocks - 1]. But when state_from_hbm, allocate pins boundary_hash (begin_restore) before _fresh_block, and that image's units were counted as spendable. On an exact-fit pool _fresh_block then raises (repro in the review summary). The "compressed floor above the unaligned gated hit" path (claim >= hbm) skips the re-check the same way. Re-checking whenever state_from_hbm, with protected=boundary_hash, regardless of extra, closes it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4124e7e: _fit_joint now re-checks every state_from_hbm joint, aligned or not and whether or not the tail adds in-use blocks, against protected=boundary_hash. Your exact-fit repro now skips the joint and keeps the HBM hit instead of raising in _fresh_block (covered in tests/test_state_checkpoint.py).

Comment thread atom/model_engine/block_manager.py Outdated
num_new_blocks -= 1
if decision.state_from_hbm:
protected: int | None = decision.boundary_hash
elif num_cached_blocks:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the state comes from the tier, this protects the HBM-hit checkpoint, but in a joint allocate attaches boundary_hash (a tier load) and never pins the hit checkpoint. So units that could really be freed are excluded. Case: the tail is used by a live seq, the tier holds the rung, and the only evictable unit is the hit's own HBM checkpoint. The real demand fits, but this reports no room, so the admission is refused as partial_chunk_no_room and the CPU prefix this PR recovers is lost. The docstring's "the checkpoint allocate is about to pin" does not hold for this branch; protected=None fits it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4124e7e: for a tier joint the re-check uses protected=None, so the hit image can fund the private tail and a joint that fits is no longer refused as partial_chunk_no_room.

Comment thread atom/model_engine/block_manager.py Outdated
extra += 1
if extra == 0:
return decision
num_new_blocks = self._n_hash_blocks(seq)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This rebuilds the num_new_blocks that can_allocate just computed by re-walking [0, num_cached_blocks) with kv.lookup and is_used, after walking [claim, hbm) above. For a 200k-token prompt with 16-token hash blocks that is about 12.5k extra dict lookups per qualifying admission, in the scheduler's admission loop. It also leaves out can_allocate's token_ids collision check, and the two copies of the demand formula can drift (only one guards block_id != -1). Carrying the value on the probe or on JointBoundaryDecision would avoid both.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4124e7e: the demand is now the num_new_blocks that can_allocate already computed (kept on the seq as _admission_num_new_blocks) plus the in-use blocks of the partial chunk [claim, hbm); the hit is no longer walked a second time.

assert (
claim_tokens % hbs == 0
), "a joint claim below the HBM hit is hash-block aligned"
num_cached_blocks = claim_tokens // hbs

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lowering num_cached_blocks to the chunk floor makes an unaligned admission all-or-nothing. Before, it resumed for certain at the HBM hit from that hit's checkpoint. Now any failure after the joint is committed goes through disown_claimed_prefix and recomputes from token 0, not from hbm. Ways it can fail: the connector refuses in _decide_load_after_alloc (joint_boundary_above_lookup); begin_restore misses because another seq's _fresh_block in the same pass evicted the boundary image; LMCache partly misses (the PR's own data has 16/99 boundaries failing with rank-0 lookup); a state load fails. That can mean recomputing hundreds of thousands of tokens. Falling back to hbm on these paths would keep the old floor; none of them is tested.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 4124e7e: hbm_hit_tokens records the gated hit, and if either leg fails retreat_joint_to_hbm restores the shared blocks and that hit's state, so the request resumes at the HBM hit; token 0 is used only when the hit itself is gone. 0f805f0 fixes the follow-up assert so a retreated joint continues prefill on its existing blocks.

Comment thread atom/model_engine/block_manager.py Outdated
# refills `[floor, hbm)` privately.
claim_tokens = ((claim_blocks * hbs) // chunk) * chunk
claim_tokens = max(claim_tokens, hbm_boundary * hbs)
if claim_tokens < hbm_tokens:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This branch never changes the value. claim_blocks = min(len(block_hashes), candidate) >= hbm_boundary, so the floored claim_tokens is already >= the chunk floor of hbm_tokens; when it is below hbm_tokens it already equals (hbm_tokens // chunk) * chunk, and an aligned hbm_tokens never enters. The real change is dropping max(claim_tokens, hbm_tokens). The conditional and its comment describe a step that never happens; removing the branch says what the code does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 4124e7e. The claim is the compressed hit's chunk floor, and the comment there now says why it is not lifted to hbm_tokens.

…e HBM hit

A state-from-HBM joint pinned the boundary image after can_allocate had
counted it as free, so an exact-fit pool raised in _fresh_block. Re-check
every such joint against that image, and when an unaligned joint fails
resume at the gated HBM hit instead of token 0.
@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

Addressed the review on 4124e7e84.

  1. Pinned checkpoint. _fit_joint re-checks every state_from_hbm joint, including aligned hits and tails that add no in-use blocks, with protected=boundary_hash and demand num_new + in_use[claim, hbm). The exact-fit repro (boundary image in HBM, hit only on the tier, free = demand − one image) now skips the joint and keeps the HBM hit instead of raising in _fresh_block. The same check covers the aligned hit.

  2. Tier joint. When the state comes from the tier, the re-check uses protected=None. The hit image is not pinned, so it can fund the private tail. Protecting it was refusing a joint that fits.

  3. No second walk of the hit. The demand is the num_new_blocks can_allocate already computed, plus the in-use blocks of the partial chunk only.

  4. Failed unaligned joint. hbm_hit_tokens records the gated hit. If either leg fails, retreat_joint_to_hbm puts the shared blocks and that hit's state back and clears the joint. Token 0 is only used when the hit itself can no longer be recovered. prefix_cache_hit_tokens records the HBM hit, not the chunk floor, so [floor, hbm) is not counted as a CPU-tier load.

  5. Dead branch. The claim_tokens < hbm_tokens assignment never changed the value. The claim is the compressed hit's chunk floor; the old max(..., hbm_tokens) is what was dropped.

_fresh_block's docstring now names the boundary image as the checkpoint the fit gate protects. tests/test_state_checkpoint.py and tests/test_block_manager.py: 280 passed.

Keep the unaligned joint fit check beside the new prefix walk. The walk
still supplies the admission's num_new_blocks, and allocate claims only
up to the chunk floor.
A failed unaligned joint that recovered the HBM hit kept its block table
but cleared both offload-resume flags, so the next admission called
allocate and asserted. Mark that path as a failed load so prefill
continues from the hit.
@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

0f805f0ba fixes the crash that stopped the post-merge re-run of afbf38c38.

Crash. On afbf38c38, c56 profiling died about 3 minutes in. Request 709's LMCache retrieve returned 40960/40960 tokens, the joint was then reported failed, and the next admission hit assert not seq.block_table in BlockManager.allocate. aiperf held the 3600s window open on a dead engine (TTFT coverage 4.5%) and exited 1. No throughput number from that run.

Cause. retreat_joint_to_hbm had already put the HBM hit back into the block table and cleared the joint. That path also cleared offload_load_failed, so resumes_offload_prefill was false and admission called allocate again. The fix marks this recovered hit as a failed load. Admission resumes prefill on the existing blocks. A hit whose state still has to be fetched stays parked.

What "KV load failed" is. The log line is the joint pair, not a KV miss. Over the hour below, that line fired 40 times and state_offload_loads_failed is 40 (197 attempted, 157 completed). For request 709 every rank logged Retrieved 40960 out of 40960. The state get for the boundary checkpoint returned empty while the engine index still advertised the hash; LMCache LRU had dropped the image. Either leg failing fails the pair, so the scheduler prints the KV line and prefills again from the HBM hit (16 tokens in these cases).

Accuracy, measured on afbf38c38 before this scheduler change. Same protocol as the PR: TP8, FP8 KV, PYTHONHASHSEED=0, LMCache CPU 32 GiB, chunk 256, MAX_NUM_SEQS=65, GSM8K 3-shot, 1319 questions, concurrency 65.

flexible-extract strict-match
05e502613 0.8749 ± 0.0091 0.8590 ± 0.0096
afbf38c38 0.8688 ± 0.0093 0.8544 ± 0.0097

flexible-extract is 0.0061 lower, inside 1 SE of the previous run.

Performance, measured on 0f805f0ba. Same c56 protocol as the PR "This PR" column: rank-0 lookup, TP4, MTP3, GPU 0–3, GPU_MEM_UTIL=0.8, MAX_NUM_SEQS=112, LMCache CPU 253 GiB, chunk 256, OFFLOAD_MIN_LOAD_TOKENS=8192, concurrency 56, 3600s, warmup excluded. Coverage 100%. Phase complete: 4307 finished, 12 cancelled when the grace period expired, 5 InvalidInferenceResultError (empty content). Exit 0.

05e502613 0f805f0ba
output tok/s 1009.0 (252.2/GPU) 1068.0 (267.0/GPU)
prompt cache hit 94.51% (357,647,440 / 378,433,807) 94.39% (372,891,024 / 395,067,042)
theoretical prefix hit 95.45% 95.37%
TTFT p50 / p99 1219 / 7343 ms 1204 / 6360 ms
ITL p50 45.4 ms 44.2 ms
success 4093 4302

The fixed-duration replay mix is not the same across runs, so the throughput gap is not only this change. This is LMCache-on only; the on/off comparison in the issue checklist was not re-run.

@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

Accuracy re-run on 0f805f0ba (the joint-resume fix). Same protocol as the previous GSM8K column: TP8, FP8 KV, PYTHONHASHSEED=0, LMCache CPU 32 GiB, chunk 256, MAX_NUM_SEQS=65, GPU mem 0.9, prefix caching, 3-shot, 1319 questions, concurrency 65, local-completions.

flexible-extract strict-match
05e502613 0.8749 ± 0.0091 0.8590 ± 0.0096
afbf38c38 0.8688 ± 0.0093 0.8544 ± 0.0097
0f805f0ba 0.8787 ± 0.0090 0.8643 ± 0.0094

flexible-extract is 0.0099 above afbf38c38, inside 1 SE of that run (±0.0093), and 0.0038 above 05e502613. The earlier accuracy number in #2562 (comment) was measured on afbf38c38, before this scheduler change. The c56 number in that comment is already on 0f805f0ba.

@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

Ready for review. Retest after merging main is in the body, at 658aa38. GSM8K stays above the 0.85 gate. The AgentX replay is in the same range as the 05e5026 column.

@valarLip

Copy link
Copy Markdown
Collaborator

@ZhangLirong-amd

No conflicts. Picks up #2669 (fix(mla): migrate Triton decode to maintained
aiter API): the 10-20 heavy accuracy jobs failed at import with
ModuleNotFoundError: aiter.ops.triton.attention.mla_decode, raised from this
branch's old triton_mla.py against the current aiter wheel, so a re-run of
that merge ref would fail the same way.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

Merged origin/main at 5fea029 (#2606) into this branch (merge a43c335). It was a clean merge with no conflicts; no rebase or force-push.

Why a merge instead of rerunning the failed jobs: in run 39385165211, the GLM-5.3-Flash, Kimi-K3 and Kimi-K3 DSpark accuracy jobs fail at atom/model_ops/attentions/triton_mla.py:7, which imports aiter.ops.triton.attention.mla_decode. That module was removed from aiter; main moved off it in #2669, which this branch didn't have yet. A rerun reuses the old merge ref, so it would fail the same way. The DeepSeek-V4-Pro job failed differently: GPU Hang during server init on the CI machine. The merge brings #2669 in, and ci:atom reruns the heavy tests on the new head.

@valarLip all five review threads now have replies, each pointing to the commit that addressed it (4124e7e; 0f805f0 also covers the retreated-joint follow-up).

Verified (MI355X, rocm/atom-dev:latest + CI aiter wheel):

  • black/ruff (CI versions) clean.
  • CPU unit tests (.github/scripts/run_unit_tests.sh): 3 failed / 9731 passed. These are the same 3 failures as unmodified main.
  • GPU: test_state_checkpoint.py, test_block_manager.py and test_scheduler.py: 558 passed. Main gives 548 passed on the same files.

@ZhangLirong-amd ZhangLirong-amd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-checked at a43c335: the PR's own changes are identical to 658aa38, and the 10-15 points are addressed. Two remaining comments inline.

Comment thread atom/model_engine/block_manager.py Outdated
# has nothing new to reject.
if not decision.state_from_hbm and extra == 0:
return decision
num_new_blocks = getattr(seq, "_admission_num_new_blocks", None)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When _admission_num_new_blocks is missing, _fit_joint returns the decision unchecked, so the joint is committed without the re-check that prevents the exact-fit crash in _fresh_block. It cannot be missing in today's call order (can_allocate sets it on every probe, record_allocation runs after it in the same pass), but that ordering is an implicit contract carried by an undeclared dynamic attribute on Sequence, and the guard fails open: a future caller that commits without probing first, or a probe path that returns before line 1263, would silently bring the crash back. It is also never cleared, so a stale value from an earlier admission attempt could be read if the order ever changes.

Could the demand travel with the probe instead, e.g. as a field on JointBoundaryDecision / the probe result, or as a declared field on Sequence reset after use? At minimum, fail closed here: skip the joint (self._no_joint(...)) or assert, rather than passing it through.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ead6826: the demand now travels with the probe, and the guard fails closed.

  • New AdmissionProbe (hit, walked chain, num_new_blocks). can_allocate(..., probe=...) resets num_new_blocks to None on every probe and sets it only once the fit check passes, so a refused re-probe cannot carry an earlier value. The _admission_num_new_blocks attribute on Sequence is gone.
  • record_allocation(seq, probe) takes the probe. The scheduler's _probe_hbm_prefix returns it and hands the same object to record_allocation; a direct admission (record=True) builds one from _evaluate's locals.
  • _fit_joint(decision, probe): if probe.num_new_blocks is None, it returns _no_joint("no_probe_demand"). The check comes before every early return, including the tier-joint-without-private-tail shortcut, which never read the demand before. I chose a skip over an assert because skipping only falls back to the HBM hit. The new reason shows up in joint_skips.

Tests in tests/test_state_checkpoint.py::TestPagedCopyCheckpoint:

  • test_the_unchecked_exact_fit_joint_does_crash_allocate: on the exact-fit pool (now _exact_fit_boundary_image_pool), committing the joint without _fit_joint makes allocate raise No PAGE unit available.
  • test_a_joint_without_probe_demand_is_skipped_not_committed: on the same pool, a probe without demand skips the joint (joint_skips == {"no_probe_demand": 1}), and allocate succeeds at the HBM hit.
  • test_a_tier_joint_without_probe_demand_is_skipped_too: covers the shortcut path.
  • test_a_refused_probe_keeps_no_demand_from_an_earlier_one: the stale-value case.

Restoring the old pass-through makes the two no_probe_demand tests fail.

Comment thread atom/model_engine/block_manager.py Outdated
h = seq.cache_seed
for i in range(hbm_blocks):
token_ids = self._hash_block_tokens(seq, i)
h = self.compute_hash(token_ids, h)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This recomputes the prompt's hash chain from cache_seed block by block, while can_allocate / allocate use _prompt_hashes(seq), which may adopt the frontend's shipped chain (_adopt_shipped_chain). Two sources for the same chain: if they ever diverge (hash function, seed handling, block size), every lookup here misses and the retreat silently fails, falling back to token 0 -- exactly the recompute this function exists to avoid, with nothing in the logs to say why. It is also an extra O(hbm_blocks) hash pass on a failure path that can cover hundreds of thousands of tokens.

Reading the first hbm_blocks entries of self._prompt_hashes(seq) here (the chain can_allocate walked) would keep one source of truth and drop the rehash; the token_ids comparison on each block can stay as the collision check.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ead6826. retreat_joint_to_hbm now reads the HBM hit from self._prompt_hashes(seq), the chain can_allocate walked, which may be the adopted frontend chain. It goes through _block_hash(seq, prompt, i, h) the way _walk_prefix does: an entry the chain already holds is read rather than rehashed, so the extra hash pass is gone. The per-block token_ids comparison stays as the collision check. A miss now logs [JOINT-RETREAT] seq ...: HBM hit block i of n is gone at debug level, so a failed retreat says where it stopped.

Test: test_a_failed_unaligned_joint_resumes_at_the_hbm_hit is parametrized. In the shipped_chain case, the adopted chain is minted from a different seed, so it differs from what this engine would hash from cache_seed (the test asserts the first link differs). The retreat still lands on the HBM hit, with the shared tail block and the restore queued. Switching the loop back to compute_hash from cache_seed makes that case fail.

…its chain

Review follow-ups on the unaligned joint (#2562):

- The demand `can_allocate`'s fit passed now travels with the probe
  (`AdmissionProbe`: hit, walked chain, `num_new_blocks`) into
  `record_allocation(seq, probe)` instead of an undeclared, never-cleared
  `Sequence` attribute. `_fit_joint` fails closed: a probe without demand
  skips the joint (`no_probe_demand`) before any shortcut. Each probe resets
  the demand, so a refused re-probe cannot carry an earlier one.
- `retreat_joint_to_hbm` reads the HBM hit off `_prompt_hashes(seq)`, the
  chain `can_allocate` walked (possibly the frontend's), instead of rehashing
  from `cache_seed`. The per-block token comparison stays as the collision
  check; a miss is logged at debug.

Tests: the unchecked exact-fit joint crashes `allocate`; without probe demand
the joint is skipped (exact-fit and tier-joint cases); a refused re-probe
clears the demand; the retreat resumes at the HBM hit with a shipped chain
that differs from the locally hashed one.
@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

@valarLip @ZhangLirong-amd Both inline comments are addressed in ead6826 (a43c3350..ead6826d, fast-forward, no main merge: it still merges cleanly with main eebccafb). Diff: 5 files, +226/−63.

_fit_joint failing open (reply): the fit demand now travels with the probe. AdmissionProbe carries the hit, the walked chain and num_new_blocks. The scheduler hands the same object to record_allocation(seq, probe). Each probe resets the demand and sets it only once the fit passes, and the undeclared Sequence attribute is gone. _fit_joint fails closed: no demand means _no_joint("no_probe_demand"), checked before every shortcut.

Retreat rehashing from cache_seed (reply): retreat_joint_to_hbm reads the hit from _prompt_hashes(seq), the chain can_allocate walked, via _block_hash, so there is no second hash pass. The token comparison stays as the collision check, and a miss logs which block it stopped at.

The PR description now covers both.

Validation:

  • New tests in TestPagedCopyCheckpoint:

    • On the exact-fit pool, the unchecked joint crashes allocate, and without probe demand the joint is skipped.
    • The tier-joint shortcut is also skipped without demand.
    • A refused re-probe clears the demand.
    • The retreat resumes at the HBM hit when the adopted chain differs from the locally hashed one.
    • Reverting either fix makes its tests fail.
  • CPU unit suite (run_unit_tests.sh): 3 failed / 9736 passed. The 3 failures are the same ones main already fails (test_graph_marker ×2, test_sparse_indexer_side_effect_op). black 26.10.1 / ruff 0.17.2 clean.

  • GPU (MI355X): test_state_checkpoint.py, test_block_manager.py and test_scheduler.py give 563 passed (558 at a43c335, plus the 5 new tests). The same files on main + this PR + fix(qwen3.5): keep PAGE checkpoint images out of the KV rows decode reads #2635 give 568 passed.

  • CPU retrieval accuracy on main + this PR + fix(qwen3.5): keep PAGE checkpoint images out of the KV rows decode reads #2635 (cfa9b4c1). Setup: Qwen3.5-35B-A3B-FP8, TP1, bf16 KV, in-process LMCache, GSM8K questions 0–439 run twice.

    pass 1 (store) pass 2 (from CPU)
    Accuracy 0.9659 0.9659
    Requests served from CPU — 373 / 440 (74.3% of prompt tokens)
    State loads (completed / failed) — 373 / 0
    Joint boundaries — 373
    Empty / truncated 6 / 7 3 / 4

    5 questions flipped each way between the passes. The earlier run with fix(qwen3.5): keep PAGE checkpoint images out of the KV rows decode reads #2635's fix (a43c335 + cfa9b4c1) scored 0.9682 / 0.9750 on the same setup; that gap is about one standard error (0.0087). No no_probe_demand skips fired in serving.

Merge check: #2636 still conflicts only in tests/test_state_checkpoint.py, in the same hunk, and keeping both sides passes. Against #2635 this adds one trivial conflict, also in tests/test_state_checkpoint.py, on the top import line (AdmissionProbe next to #2635's new ConnectorMetadata import). Keeping both resolves it.

Could you take another look?

@ZhangLirong-amd
ZhangLirong-amd merged commit 035a78a into main Oct 28, 2026
84 of 101 checks passed
@ZhangLirong-amd
ZhangLirong-amd deleted the lingzha/qwen35-lmcache branch October 28, 2026 06:05
ZhiweiYan-96 added a commit that referenced this pull request Oct 28, 2026
Resolve against #2562 (035a78a), which added `AdmissionProbe`:
- block_manager `_evaluate`: keep main's positional `probe` out-param and the
  PR's keyword-only `reserved_blocks` / `reserved_state_slots` / `demand` /
  `observe`; after the fit passes, fill both `demand` and
  `probe.num_new_blocks`.
- scheduler fill probe: `_can_detach_tier_lookup` now takes an
  `AdmissionProbe`, so pass `AdmissionProbe(cached_blocks)` instead of the
  old `(hit, hashes)` tuple.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants